---
id: dbcontext-audit
title: 9.22 审计日志
sidebar_label: 9.22 审计日志 (Audit)
---

## 9.22.1 审计日志

在一个企业应用系统中，用户对系统所有的操作包括请求、数据库操作等等都应该记录起来，那么这些日志我们称为操作日志，也可以说审计日志。

**通常来说，我们审计日志更多指的是数据库的操作记录**。

审计日志一般会记录以下三个操作：

- `新增操作`：记录某某人在某某时间对哪个表新增了什么数据
- `更新操作`：记录某某人在某某时间对哪个表的哪些数据做了更改，记录更改前的值和更改后的值
- `删除操作`：记录某某人在某某时间对哪个表删除了什么数据

## 9.22.2 关于 `SaveChanges` 事件

`Furion` 框架中为所有 `AppDbContext` 子类都提供了三个可重写的方法，这三个方法分别由三个事件触发：

- `提交更改之前 SavingChanges 事件`：触发 `void SavingChangesEvent(DbContextEventData eventData, InterceptionResult<int> result)` 方法
- `提交更改之后 SavedChanges 事件`：触发 `void SavedChangesEvent(SaveChangesCompletedEventData eventData, int result)` 方法
- `提交更改失败 SaveChangesFailed 事件`：触发 `void SaveChangesFailedEvent(DbContextErrorEventData eventData)` 方法

通过这三个事件我们就可以捕获所有更改的实体然后保存到数据库审计日志中。

## 9.22.3 如何实现

### 9.22.3.1 数据库审计日志

我们只需要在 `AppDbContext` 子类中重写 `SavingChanges` 事件对应方法即可：

```cs {23}
using Furion.DatabaseAccessor;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using System;
using System.Linq;
using System.Security.AccessControl;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class FurionDbContext : AppDbContext<FurionDbContext>
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        /// <summary>
        /// 重写保存之前事件
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        protected override void SavingChangesEvent(DbContextEventData eventData, InterceptionResult<int> result)
        {
            // 获取当前事件对应上下文
            var dbContext = eventData.Context;

            // 强制重新检查一边实体更改信息
            // dbContext.ChangeTracker.DetectChanges();

            // 获取所有更改，删除，新增的实体，但排除审计实体（避免死循环）
            var entities = dbContext.ChangeTracker.Entries()
                .Where(u => u.Entity.GetType() != typeof(Audit) && (u.State == EntityState.Modified || u.State == EntityState.Deleted || u.State == EntityState.Added))
                .ToList();

            // 通过请求中获取当前操作人
            var userId = App.GetService<IHttpContextAccessor>().HttpContext.Items["UserId"];

            // 获取所有已更改的实体
            foreach (var entity in entities)
            {
                // 获取实体类型
                var entityType = entity.Entity.GetType();

                // 获取所有实体有效属性，排除 [NotMapper] 属性
                var props = entity.OriginalValues.Properties;

                // 获取实体当前（现在）的值
                var currentValues = entity.CurrentValues;

                // 获取数据库中实体的值
                var databaseValues = entity.GetDatabaseValues();

                // 遍历所有属性
                foreach (var prop in props)
                {
                    // 获取属性名
                    var propName = prop.Name;

                    // 获取现在的实体值
                    var newValue = currentValues[propName];

                    object oldValue = null;
                    // 如果是新增数据，则 databaseValues 为空，所以需要判断一下
                    if (databaseValues != null)
                    {
                        oldValue = databaseValues[propName];
                    }

                    // 插入审计日志表，Audit 是你自定义的实体
                    dbContext.Set<Audit>().Add(new Audit
                    {
                        Table = entityType.Name,    // 表名
                        Column = propName,  // 更新的列
                        NewValue = newValue,    // 新值
                        OldValue = oldValue,    // 旧值
                        CreatedTime = DateTime.Now, // 操作时间
                        UserId = userId,    // 操作人
                        Operate = entity.State.ToString();  // 操作方式：新增、更新、删除
                    });
                }
            }
        }
    }
}
```

:::tip 小知识

如果对性能有所要求，那么建议审计日志通过 `日志组件` 写入数据库，如通过 `Nlog、Log4Net` 这些等：

```cs
// 插入审计日志表
dbContext.Set<Audit>().Add(new Audit
{
    Table = entityType.Name,    // 表名
    Column = propName,  // 更新的列
    newValue = newValue,    // 新值
    OldValue = oldValue,    // 旧值
    CreatedTime = DateTime.Now, // 操作时间
    UserId = userId,    // 操作人
    Operate = entity.State.ToString();  // 操作方式：新增、更新、删除
});
```

替换为：

```cs
logger.Information(JsonConvert.SerializeObject(new Audit
{
    Table = entityType.Name,    // 表名
    Column = propName,  // 更新的列
    newValue = newValue,    // 新值
    OldValue = oldValue,    // 旧值
    CreatedTime = DateTime.Now, // 操作时间
    UserId = userId,    // 操作人
    Operate = entity.State.ToString();  // 操作方式：新增、更新、删除
}));
```

:::

通过上面的例子，我们就可以对数据库所有的新增、更新、删除进行监控了。

### 9.22.3.2 执行 `sql` 审计日志

主要通过 `DbCommandInterceptor` 拦截实现，具体使用可查看 [数据库拦截器 - DbCommandInterceptor](./dbcontext-Interceptor#92422-dbcommandinterceptor)，如：

```cs {11}
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;

namespace Furion.Web.Core
{
    /// <summary>
    /// 执行 sql 审计
    /// </summary>
    public sealed class SqlCommandAuditInterceptor : DbCommandInterceptor
    {
        public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
        {
            // 获取执行的 sql 语句
            var sql = command.CommandText;

            // 获取执行的 sql 类型，是 sql 语句，还是存储过程，还是其他
            var type = command.CommandType;

            // 获取 sql 传递的命令参数
            var parameters = command.Parameters;

            // 写日志~~~~

            return base.NonQueryExecuting(command, eventData, result);
        }

        public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
        {
            // 获取执行的 sql 语句
            var sql = command.CommandText;

            // 获取执行的 sql 类型，是 sql 语句，还是存储过程，还是其他
            var type = command.CommandType;

            // 获取 sql 传递的命令参数
            var parameters = command.Parameters;

            // 写日志~~~~

            return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
        }

        // 其他 override
    }
}
```

### 9.22.3.3 请求审计日志

除了上面的数据库增删改审计日志以外，我们还可以实现请求审计日志，也就是记录请求地址，来源地址，操作人，传递参数等。这个主要是通过 `IAsyncActionFilter` 筛选器实现，如：

1. 定义 `RequestAuditFilter` 并实现 `IAsyncActionFilter`

```cs {9,45}
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Security.Claims;
using System.Threading.Tasks;

namespace Furion.Web.Core
{
    public class RequestAuditFilter : IAsyncActionFilter
    {
        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            //============== 这里是执行方法之前获取数据 ====================

            // 获取控制器、路由信息
            var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;

            // 获取请求的方法
            var method = actionDescriptor.MethodInfo;

            // 获取 HttpContext 和 HttpRequest 对象
            var httpContext = context.HttpContext;
            var httpRequest = httpContext.Request;

            // 获取客户端 Ipv4 地址
            var remoteIPv4 = httpContext.GetRemoteIpAddressToIPv4();

            // 获取请求的 Url 地址
            var requestUrl = httpRequest.GetRequestUrlAddress();

            // 获取来源 Url 地址
            var refererUrl = httpRequest.GetRefererUrlAddress();

            // 获取请求参数（写入日志，需序列化成字符串后存储）
            var parameters = context.ActionArguments;

            // 获取操作人（必须授权访问才有值）"userId" 为你存储的 claims type，jwt 授权对应的是 payload 中存储的键名
            var userId = httpContext.User?.FindFirstValue("userId");

            // 请求时间
            var requestedTime = DateTimeOffset.Now;


            //============== 这里是执行方法之后获取数据 ====================
            var actionContext = await next();


            // 判断是否请求成功，没有异常就是请求成功
            var isRequestSucceed = actionContext.Exception == null;

            // 这里写入日志~~~~~~~~~~~~~~~~~~~~
        }
    }
}
```

2. 注册 `RequestAuditFilter` 筛选器

```cs
services.AddMvcFilter<RequestAuditFilter>();
```

:::important 关于筛选器注册顺序

通常先注册的会先执行，也可以通过实现 `IOrderedFilter` 接口配置 `Order` 属性，数值越小，越先执行。

:::

## 9.22.4 反馈与建议

:::note 与我们交流

给 Furion 提 [Issue](https://gitee.com/dotnetchina/Furion/issues/new?issue)。

:::
